Skip to content

feat(pm-stack): project spend attribution facts (#4822) - #5164

Merged
dezren39 merged 4 commits into
mainfrom
queue/8769-4822-spend-attribution-projection
Nov 8, 2026
Merged

dezren39 merged 4 commits into
mainfrom
queue/8769-4822-spend-attribution-projection

Conversation

@dezren39

@dezren39 dezren39 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Summary

usage sessions
  → group children under root (sum each session once)
  → exact lane binding, else unambiguous fleet worktree, else reason-coded unattributed
  → issue / epic / explicitly mapped project facts

Adds a pure v1 projection in pm-stack/aggregator/src/spend-attribution.ts. It conserves input, output, reasoning, cache-read, and cache-write tokens independently; failed/missing exports stay unknown (source-gap), not zero. Area is not used as a project key, and list-price estimates are not treated as dollars.

Evidence

  • Before: no spend-attribution projection/version existed in the aggregator.
    After: bun test test/spend-attribution.test.ts — 7 pass; focused strict TypeScript check of the new module and tests — pass; git diff --check — pass.
  • Full local bun test: 1425 pass, 2 failures in existing /api/health assertions in test/api-health-comments.test.ts (outside this diff). Full bunx tsc --noEmit reports the existing test/overview.test.ts HealthView.conflicts fixture error; the changed files pass the focused strict check.

Merge Danger

Door: two-way

Blast Radius: low — new pure module and unit tests; no collector, exporter, or UI wiring changes.

Closes #4822.


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

@dezren39
dezren39 requested a review from a team as a code owner November 3, 2026 21:36
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 44 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8f444a56-7e23-41d2-8539-7ca307f9cf13
Commits

Reviewing files that changed from the base of the PR and between 90c5090 and e6b8d3a.

Files selected for processing: 2

  • pm-stack/aggregator/src/spend-attribution.ts
  • pm-stack/aggregator/test/spend-attribution.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-08T00:36:19.959083Z e6b8d3a New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab4c62fe30

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +208 to +210
const tokens = sumTokens(group.sessions.map((session) => session.tokens));
const providerID = root?.providerID ?? group.sessions[0]?.providerID ?? null;
const modelID = root?.modelID ?? group.sessions[0]?.modelID ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve provider and model boundaries when grouping child usage

When a child session uses a different provider or model from its root, this sums both sessions' tokens but labels the entire fact with the root's provider/model. For example, an OpenAI root with an Anthropic child produces an OpenAI-tagged fact containing Anthropic consumption, while reconciliation still reports success. This loses the per-pool accounting required by doc/spend-rollups-design-1259.md (§Units), and downstream consumers cannot recover the breakdown from these facts. Retain the root's issue attribution while splitting usage facts by provider/model.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 19b78f9 (pushed as PR head e6b8d3a after merging current main): projectSpendAttribution now splits each root group's usage into one fact per (providerID, modelID) subgroup, so a child on a different provider/model no longer has its tokens summed under the root's provider/model identity. Issue/epic/project attribution stays with the root group for every subgroup, and byIssue/byEpic/byProject totals are accumulated per fact, so reconciliation still conserves every token dimension. Regression test: 'splits a root group by provider and model while keeping root issue attribution' in test/spend-attribution.test.ts (bun test: 9 pass). — queue worker lane code-z, task 11311

const tokens = sumTokens(group.sessions.map((session) => session.tokens));
const providerID = root?.providerID ?? group.sessions[0]?.providerID ?? null;
const modelID = root?.modelID ?? group.sessions[0]?.modelID ?? null;
const binding = root ? bindingsBySession.get(root.id) : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consult exact bindings even when the root session is absent

When an exported child references a parent outside the source window, collectRootGroups retains that missing parent's ID as group.rootSessionId, but this guard prevents looking up its binding. Thus a supplied binding for that exact ID is discarded and all child usage becomes unattributed despite authoritative issue information being available. This is a realistic input because opencode-usage-export.mjs filters sessions independently by time_updated >= ?. Look up the binding using group.rootSessionId regardless of whether the root's session row exists; the missing-parent reason can still record the incomplete source context.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 19b78f9 (pushed as PR head e6b8d3a after merging current main): the binding lookup is now bindingsBySession.get(group.rootSessionId) unconditionally — the previous root ? ... : undefined guard discarded a supplied binding whenever the root session row was outside the source window. The parent-left-pool reason still records the incomplete source context when the issue is attributed this way. Regression test: 'consults the exact binding for a root outside the source window' in test/spend-attribution.test.ts (bun test: 9 pass). — queue worker lane code-z, task 11311

@dezren39

dezren39 commented Nov 7, 2026

Copy link
Copy Markdown
Member Author

Stale-PR warden: no activity for 3 days. Resolution task: #6912 — this PR should be merged, fixed, or closed.

repo-worker-code-g added 2 commits November 7, 2026 18:31
…ing when root row absent

Addresses both Codex P2 findings on PR #5164:
- facts are now split by (providerID, modelID) within a root group so one
  pool's consumption is never labelled with another pool's identity,
  while issue attribution stays with the root group;
- the exact lane binding is looked up by group.rootSessionId even when
  the root session row is outside the source window.
Regression tests cover both cases; bun test test/spend-attribution.test.ts 9 pass.
@dezren39
dezren39 merged commit 9e9bb8b into main Nov 8, 2026
1 of 5 checks passed
@dezren39
dezren39 deleted the queue/8769-4822-spend-attribution-projection branch November 8, 2026 00:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e6b8d3aa50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let reason: SpendUnattributedReason | null = null;
if (group.parentLeftPool) reason = "parent-left-pool";
else if (!issueRef) reason = root?.directory && fleetIssueNumber(root.directory) !== null ? "no-worktree" : "no-binding";
else if (!poolItem || (poolItem.parentRef && !poolByRef.has(poolItem.parentRef))) reason = "parent-left-pool";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect hierarchy gaps beyond the immediate parent

When an attributed issue's parent exists in the fetched pool but its grandparent is missing, this check leaves reason null despite incomplete epic resolution. resolveEpicRefs in projects.ts follows the entire ancestor chain and omits epicRef when that chain leaves the pool, so this scenario silently looks like an issue with no epic rather than missing hierarchy context. Walk the relevant ancestry cycle-safely and report parent-left-pool when a required ancestor is absent.

Useful? React with 👍 / 👎.

Comment on lines +85 to +86
test("rolls nested sessions into their root once and reconciles every token dimension", () => {
const projection = projectSpendAttribution({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add property coverage for token conservation

The projection promises independent conservation of all five token dimensions, but this test and the remaining coverage use only fixed examples. Root AGENTS.md explicitly requires property tests for meaningful invariants and says example tests do not replace them. Add generated session forests with independently varied token dimensions, provider/model splits, bindings, and missing parents, checking that every session appears exactly once and that attributed plus unattributed totals equal source totals per dimension.

AGENTS.md reference: AGENTS.md:L41-L45

Useful? React with 👍 / 👎.

Comment on lines +95 to +96
const match = /^(\d+)-(\d+)-[^/\\]+$/.exec(directoryBase(directory));
return match ? Number(match[2]) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize the established fallback fleet worktree names

For an unbound session in /home/user/git/fleet/fb-6615-records or /home/user/git/fleet/6635-issue-6635-exec, this regex rejects the directory even when its issue uniquely exists in the pool, leaving the usage unattributed with no-binding. These naming conventions are documented and parsed in infra/cargo-target-janitor/bin/cargo_target_janitor.py:53–63, and its tests at infra/cargo-target-janitor/tests/test_cargo_target_janitor.py:371–382 explicitly cover them. Fallback mode has no broker task IDs, so queue bindings cannot be relied on to recover this attribution. Support the <task>-issue-<issue>-<slug>, fb-<issue>-<slug>, and fallback-<issue>-<slug> forms while retaining the unique-pool-match check.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

impl(pm-stack): spend attribution projection — session to issue/epic resolution, unattributed shown (#1259 slice 1)

1 participant